跳转至

5 Applying Complex Transformations & Interactions

In the previous chapter, you learned how to draw a custom seating chart with tribunes using SwiftUI’s Path. However, quite a few things are still missing. Users must be able to preview the seats inside a tribune and select them to purchase tickets. To make the user’s navigation through the chart effortless and natural, you’ll implement gesture handling, such as dragging, magnifying and rotating.

As usual, fetch the starter project for this chapter from the materials, or continue where you left off in the previous chapter.

Open SportFan.xcodeproj and head straight to SeatingChartView.

Manipulating SwiftUI Shapes Using CGAffineTransform

You need two things to display seats for each tribune: a Shape containing the Pathdrawing the seat and a CGRect representing its bounds. To accomplish the former, create a new struct named SeatShape:

struct SeatShape: Shape {
  func path(in rect: CGRect) -> Path {
    Path { path in

    }
  }
}

img

The shape you’re about to draw consists of a few parts: the seat’s back, squab, and rod connecting them. Start by defining a few essential properties right below inside the Path’s trailing closure:

let verticalSpacing = rect.height * 0.1
let cornerSize = CGSize(
  width: rect.width / 15.0,
  height: rect.height / 15.0
)
let seatBackHeight = rect.height / 3.0 - verticalSpacing
let squabHeight = rect.height / 2.0 - verticalSpacing
let seatWidth = rect.width

To emulate the top-to-bottom perspective, you calculate the seat back rectangle as slightly shorter vertically than the squab.

Then, right below these variables, define the CGRect’s for the back and squab and draw the corresponding rounded rectangles:

let backRect = CGRect(
  x: 0, y: verticalSpacing,
  width: seatWidth, height: seatBackHeight
)
let squabRect = CGRect(
  x: 0, y: rect.height / 2.0,
  width: seatWidth, height: squabHeight
)

path.addRoundedRect(in: backRect, cornerSize: cornerSize)
path.addRoundedRect(in: squabRect, cornerSize: cornerSize)

Now, draw the rod:

path.move(to: CGPoint(
  x: rect.width / 2.0,
  y: rect.height / 3.0
))
path.addLine(to: CGPoint(
  x: rect.width / 2.0,
  y: rect.height / 2.0
))

You still have a long way to go before looking at the seat’s shape as part of a tribune. To get a quick preview for the time being, create a new struct called SeatPreview:

struct SeatPreview: View {
  let seatSize = 100.0
  var body: some View {
    ZStack {
      SeatShape().path(in: CGRect(
        x: 0, y: 0,
        width: seatSize, height: seatSize
      ))
      .fill(.blue) // 1

      SeatShape().path(
        in: CGRect(
          x: 0, y: 0,
          width: seatSize, height: seatSize
        ))
        .stroke(lineWidth: 2) // 2
    }
    .frame(width: seatSize, height: seatSize)
  }
}

This process is similar to the shapes you’ve drawn in the previous chapter:

  1. Inside a ZStack, you use one instance of SeatShape as a background with .blue fill.
  2. You use the second shape’s instance to draw the seat’s stroke.

Finally, you must make Xcode show the SeatPreview in the previews window. Create a new PreviewProvider:

struct Seat_Previews: PreviewProvider {
  static var previews: some View {
    SeatPreview()
  }
}

Your seat preview should look like this, for the time being:

img

The seat is there but looks relatively flat. You’ll skew it back to give it a slightly more realistic perspective. Don’t forget that you drew the tribunes all around the stadium field, which means the seats should always face the center of the field. Head to the next section to learn how to transform shapes!

Matrices Transformations

Check Path‘s API, and you’ll notice there are many methods, such as addRoundedRector addEllipse, accepting an argument of type CGAffineTransform called transform. Via just one argument, you can manipulate a subpath in 2D space in several ways: rotate, skew, scale or translate.

As you might have guessed from its prefix, CGAffineTransform is part of Apple’s Core Graphics framework, which still comes in handy in SwiftUI.

CGAffineTransform is essentially a 3x3 matrix:

img

You’ll work with the parameters a, b, c, d, tx and ty. The third column stays unchanged regardless of the transformations you apply - 0, 0 and 1.

An identity matrix is one that SwiftUI applies to a subpath by default. It performs no transformations when multiplying to another matrix:

img

When you want to apply an offset to an object, you need a translation matrix, where txrepresents the shift along the x-axis, and ty moves the object along the y-axis:

img

A scaling operation is similar as well, having only two defining parameters, sx and sy:

img

You do, however, need to use a, b, c and d to make a rotation matrix to rotate an object counterclockwise by angle a:

img

Finally, skewing an object requires applying the b or c parameters of a transformation matrix, where b skews the subpath along the y-axis, and c affects the x-axis:

img

Now, knowing all the transformation possibilities matrices offer you, you can sketch out your action plan:

  1. First, skew the seat back along the x-axis.
  2. Then rotate the entire seat’s shape by an angle it accepts from the outside to face the stadium field.
  3. Finally, translate the seat’s shape to negate the translation effect from previously rotating it since SwiftUI rotates a subpath around its (minX, minY) point. The shape will appear to rotate around its center point without shifting sideways.

Applying the Skewing Operation

Back in SeatShape’s Path, find the seatWidth you previously added, and add the following line above it:

let skewAngle = .pi / 4.0

Next, you need to calculate how much further along the x-axis the seat back goes after being skewed. You’ll account for this measurement when defining seatWidth, thus making the whole shape fit into rect. Add the following variable right below skewAngle:

let skewShift = seatBackHeight / tan(skewAngle)

img

To calculate the value of skewShift, you use a mathematical formula to find the adjacent in the right triangle by the angle’s tangent.

Now, update seatWidth:

let seatWidth = rect.width - skewShift

Next, update the rod’s final point to connect it to the center of the squab. Replace:

path.addLine(to: CGPoint(
  x: rect.width / 2.0,
  y: rect.height / 2.0
))

With:

path.addLine(to: CGPoint(
  x: rect.width / 2.0 - skewShift / 2,
  y: rect.height / 2.0
))

Here comes the exciting part! Above the addRoundedRect invocations, create a matrix to skew the seat back:

let skew = CGAffineTransform(
  a: 1, b: 0, c: -cos(skewAngle), // 1
  d: 1, tx: skewShift + verticalSpacing, ty: 0
) // 2

Here are two crucial points:

  1. You use CGAffineTransform(a:b:c:d:tx:ty:) to build a matrix on your own. You update the c value to skew the seat back along the x-axis. The minus in front of the cos of the angle defines the direction of skewing. You set it to skew the object towards the right side.
  2. Since SwiftUI transforms an object around its origin point, you shift the x value to keep the shape inside the rect’s bounds.

Finally, add the transform to the backSeat rounded rectangle. Replace:

path.addRoundedRect(in: backRect, cornerSize: cornerSize)

With:

path.addRoundedRect(
  in: backRect,
  cornerSize: cornerSize,
  transform: skew
)

Take a look at the Seat preview:

img

Rotating the Seat

To allow SeatShape to rotate, add a new property to the struct:

let rotation: CGFloat

To verify the rotation functionality in the preview, add a rotation property to SeatPreview:

@State var rotation: Float = 0.0

Pass the rotation value to the initializers of both shapes:

SeatShape(rotation: CGFloat(-rotation))

Then, wrap the root view in the preview’s body into a VStack. Then add a Sliderand a Text:

VStack {

  // ZStack with SeatShape's

  Slider(value: $rotation, in: 0.0...(2 * .pi), step: .pi / 20)
  Text("\(rotation)")
}.padding()

Now, return to SeatShape and apply a rotation matrix to the path by mutating the existing final path:

path = path.applying(CGAffineTransform(rotationAngle: rotation))

Well, that was easy, wasn’t it? Check out the preview and play around with the rotation slider:

img

Oh, it shouldn’t fly around, though! :]

Rotating an Object Around an Arbitrary Point

Applying a rotation matrix rotates an object around its origin (minX, minY). To perform the transformation around an arbitrary point like its center, you first need to shift the object to that point, perform the rotation and then translate the object back.

First, define the rotation point by adding the following variable at the very bottom of Path { } before applying the rotation transformation:

let rotationCenter = CGPoint(x: rect.width / 2, y: rect.height / 2)

Now, create the first translation matrix to shift the seat to the rotation point:

let translationToCenter = CGAffineTransform(
  translationX: rotationCenter.x,
  y: rotationCenter.y
)

Additionally, you need a translation matrix to move the seat inside the rect’s bounds:

let initialTranslation = CGAffineTransform(
  translationX: rect.minX,
  y: rect.minY
)

Now, apply the transformations step-by-step. Create a variable to keep the result of the first multiplication:

var result = CGAffineTransformRotate(translationToCenter, rotation)

Instead of directly multiplying the translationToCenter and the rotation matrix, you use CGAffineTransformRotate to apply a transformation on the translationToCenter matrix and get the result.

To translate the seat back, use CGAffineTransformTranslate as follows:

result = CGAffineTransformTranslate(result, -rotationCenter.x, -rotationCenter.y)

Finally, apply the result of multiplying initialTranslation and result to the path, and assign it to the path by replacing the last line:

path = path.applying(result.concatenating(initialTranslation))

Pay attention to the order of the matrices concatenation. In terms of matrices, a * b != b * a!

Check out the preview and move the slider’s knob around a bit to make sure the seat rotates around its center:

img

That was a bit of a challenge. Great job! Next, to calculate the bounds for each seat in all the rectangular tribunes.

Locating Rectangular Tribunes’ Seats

With your animation’s performance in mind, you’ll ensure the seat locations are computed only once, assigned to the respective tribune and drawn only when a user picks a specific tribune. Otherwise, it would be a waste to draw each one when they’re barely visible due to the scale of the seating chart.

Create a new struct to hold a seat’s path:

struct Seat: Hashable, Equatable {
  var path: Path

  public func hash(into hasher: inout Hasher) {
    hasher.combine(path.description)
  }
}

You conform Seat to Hashable to iterate over a tribune’s seats to display them. Later, you’ll enable users to pick a specific seat, so being Equatable will also come in handy.

Go to the Sector shape and create a new method:

private func computeSeats(for tribune: CGRect, at rotation: CGFloat) -> [Seat] {
  var seats: [Seat] = []

  // TODO

  return seats
}

This method will eventually calculate the bounds for the seats based on the CGRect of the tribune and the rotation.

Start by defining all the necessary values, such as size, the number of horizontal and vertical seats and spacings. Add these lines in the // TODO above:

let seatSize = tribuneSize.height * 0.1
let columnsNumber = Int(tribune.width / seatSize)
let rowsNumber = Int(tribune.height / seatSize)
let spacingH = CGFloat(tribune.width - seatSize * CGFloat(columnsNumber)) / CGFloat(columnsNumber)
let spacingV = CGFloat(tribune.height - seatSize * CGFloat(rowsNumber)) / CGFloat(rowsNumber)

Below the variables you’ve just added, create two loops to iterate over all the seats:

(0..<columnsNumber).forEach { column in
  (0..<rowsNumber).forEach { row in

  }
}

Inside the inner loop, calculate the origin points for each seat and build a CGRect:

let x = tribune.minX + spacingH / 2.0 + (spacingH + seatSize) * CGFloat(column)
let y = tribune.minY + spacingV / 2.0 + (spacingV + seatSize) * CGFloat(row)

let seatRect = CGRect(
  x: x, y: y,
  width: seatSize, height: seatSize
)

Finally, create a SeatShape, pass the rotation to it and append Seat to the array:

seats.append(Seat(
  path: SeatShape(rotation: rotation)
    .path(in: seatRect)
  )
)

Displaying the Seats

To access each tribune’s seats when rendering the seating chart, add a new property to Tribune:

var seats: [Seat]

Now, find makeRectTribuneAt(x:y:rotated:) and update its declaration to include a rotation parameter.

private func makeRectTribuneAt(
  x: CGFloat, y: CGFloat,
  vertical: Bool, rotation: CGFloat
) -> Tribune {

Note that you also removed the default value for vertical, so you’ll need to provide this in all invocations, or the compiler will throw an error. You’ll handle that shortly.

Now, create a variable for the tribune’s CGRect inside the method:

let rect = CGRect(
  x: x,
  y: y,
  width: vertical ? tribuneSize.height : tribuneSize.width,
  height: vertical ? tribuneSize.width : tribuneSize.height
)

Use rect to instantiate Tribune and calculate the seats by updating the returnstatement:

return Tribune(
  path: RectTribune().path(in: rect),
  seats: computeSeats(for: rect, at: rotation)
)

Now, the compiler will be unhappy about some missing arguments. To sort it out, pass an empty array as the last parameter to the arc tribune initializer.

At the bottom of computeArcTribunesPaths(at:corner:):

tribunes.append(Tribune(path: ArcTribune(
  /* arc tribune's properties */
).path(in: CGRect.zero), seats: []))

Then, pass the correct rotations to the makeRectTribuneAt invocations in computeRectTribunesPaths(at:corner:). You compute the top and bottom horizontal tribunes in the (0..<tribunesNumberH).forEach loop, so pass 0 and -.pi as rotation respectively:

tribunes.append(makeRectTribuneAt(
  x: x,
  y: rect.minY + offset,
  vertical: false,
  rotation: 0
))
tribunes.append(makeRectTribuneAt(
  x: x, y: rect.maxY - offset - tribuneSize.height,
  vertical: false,
  rotation: -.pi
))

For the vertical tribunes, pass -.pi / 2.0 and 3.0 * -.pi / 2.0:

tribunes.append(makeRectTribuneAt(
  x: rect.minX + offset,
  y: y,
  vertical: true,
  rotation: -.pi / 2.0
))
tribunes.append(makeRectTribuneAt(
  x: rect.maxX - offset - tribuneSize.height,
  y: y,
  vertical: true,
  rotation: 3.0 * -.pi / 2.0
))

Finally, you can display the selected tribune’s seats! Go to SeatingChartView’s bodyand add the following code after the tribunes ForEach:

if let selectedTribune {
  ForEach(selectedTribune.seats, id: \.self) { seat in
    ZStack {
      seat.path.fill(.blue)
      seat.path.stroke(.black, lineWidth: 0.05)
    }
  }
}

Run the app and select any of the non-arced tribunes:

img

Next, you’ll work on the arc tribune’s seats!

Computing Positions of the Arc Tribune’s Seats

Calculating the bounds of an arc tribune’s seats is similar to building an arc tribune’s Path. Since you move along an arc, not a straight line, you operate with angles. You used an angle value for a tribune and another for the spacing. In the same way, you’ll calculate the angle needed for a seat and the spacing between neighboring seats.

To implement it, create a new method inside Sector:

private func computeSeats(for arcTribune: ArcTribune) -> [Seat] {
  var seats: [Seat] = []

  // TODO

  return seats
}

An arc tribune has seat columns of the same size, but the rows shrink toward the stadium field. So, define the “static” variables right away in the method, instead of the // TODO mark:

let seatSize = tribuneSize.height * 0.1
let rowsNumber = Int(tribuneSize.height / seatSize)
let spacingV = CGFloat(tribuneSize.height - seatSize * CGFloat(rowsNumber)) / CGFloat(rowsNumber)

Now, add the outer loop to iterate over the rows:

(0..<rowsNumber).forEach { row in

}

Inside the loop, add variables that will dynamically change depending on the row:

let radius = arcTribune.radius - CGFloat(row) * (spacingV + seatSize) - spacingV - seatSize / 2.0 // 1
let arcLength = abs(arcTribune.endAngle - arcTribune.startAngle) * radius // 2
let arcSeatsNum = Int(arcLength / (seatSize * 1.1)) // 3

Here’s a code breakdown:

  1. For each row, you calculate the radius of a circle. You’ll place the row’s seats along an arc of this circle, just as you did when drawing the arc tribunes’ outlines.
  2. You multiply the difference between the tribune’s endAngle and startAngleby the radius to produce the length of the corresponding arc.
  3. Based on the length of the arc, you calculate the number of seats in the row. You multiply seatSize by 1.1 to give a slight spacing between the seats.

Now, add some more variables:

let arcSpacing = (arcLength - seatSize * CGFloat(arcSeatsNum)) / CGFloat(arcSeatsNum) // 1
let seatAngle = seatSize / radius // 2
let spacingAngle = arcSpacing / radius // 3
var previousAngle = arcTribune.startAngle + spacingAngle + seatAngle / 2.0 // 4

Here’s a code breakdown:

  1. To calculate the spacing, you deduct the sum of all seat sizes from the arc length and divide the result by the number of seats.
  2. Dividing seatSize by radius gives you the angle needed for each seat. Although seatSize is the measurement of a seat along a straight line, you need an arc measurement for the formula. The difference between them is negligible in this case.
  3. Applying the same formula, you calculate the angle needed for the spacing between the seats.
  4. previousAngle contains the latest offset along the arc, and you’ll update it after each seat’s calculations.

Create an inner loop below the variables:

(0..<arcSeatsNum).forEach { _ in

}

Inside the inner loop, calculate the “center” of each seat based on previousAngle:

let seatCenter = CGPoint(
  x: arcTribune.center.x + radius * cos(previousAngle),
  y: arcTribune.center.y + radius * sin(previousAngle)
)

With the approach above, you’ll iteratively move along the arc, centering the seats precisely on the arc.

Knowing the seat’s center, you can calculate its origin and bounds:

let seatRect = CGRect(
  x: seatCenter.x - seatSize / 2,
  y: seatCenter.y - seatSize / 2,
  width: seatSize,
  height: seatSize
)

Create a Seat and append it to the array:

seats.append(
  Seat(
    path: SeatShape(rotation: previousAngle + .pi / 2)
      .path(in: seatRect)
  )
)

Since the seats’ angles are perpendicular to the angle of the tribune, meaning you drew tribunes from left to right, but you draw the seats from the tribune’s top to bottom, you need to add .pi / 2 to previousAngle.

Right below, update previousAngle:

previousAngle += spacingAngle + seatAngle

Finally, back in computeArcTribunesPaths(at:corner:), find the code where you insantiate the Tribune (i.e. let tribune = ...) and update it with your new computeSeats(for:) method, like so:

let arcTribune = ArcTribune(
  center: center,
  radius: radius,
  innerRadius: innerRadius,
  startingPoint: startingPoint,
  startingInnerPoint: startingInnerPoint,
  startAngle: previousAngle + spacingAngle,
  endAngle: previousAngle + spacingAngle + angle
)

let tribune = Tribune(
  path: arcTribune.path(in: CGRect.zero),
  seats: computeSeats(for: arcTribune)
)

Run the app and try to pick a tribune:

img

Nice! It’s now time to let the user actually interact with the seats.

Processing User Gestures

Navigating through the seating chart is somewhat cumbersome and extremely limited right now. Users should be as free with gestures as possible to speed up a tribune and seat selection.

SwiftUI offers a variety of gesture handlers, most of which are valuable for the seating chart.

Dragging

To obtain the offset value from the user’s drag gesture, you’ll use SwiftUI’s DragGesture. First, add these new properties to SeatingChartView:

@GestureState private var drag: CGSize = .zero
@State private var offset: CGSize = .zero

@GestureState is a property wrapper that keeps drag up-to-date when the gesture that is ongoing and will reset it to its initial state once the user is done. The offsetproperty keeps the latest value between the gestures to avoid resetting it.

Since CGSize is the measurement for a drag gesture, add a handy extension to ease CGSizes concatenation:

extension CGSize {
  static func +(left: CGSize, right: CGSize) -> CGSize {
    return CGSize(width: left.width + right.width, height: left.height + right.height)
  }
}

Add one more property to SeatingChartView:

var dragging: some Gesture {
  DragGesture()
    .updating($drag) { currentState, gestureState, transaction in // 1
      gestureState = currentState.translation
    }
    .onEnded { // 2
      offset = offset + $0.translation
    }
}

Here’s a code breakdown:

  1. SwiftUI invokes the .updating callback repeatedly while the gesture is in progress. currentState contains the latest translation value, and changing gestureState updates the drag property.
  2. Once the gesture is over, .onEnded is invoked. There you update the offsetproperty to ensure the chart stays in place once the user lifts their finger.

Then, below .rotationEffect of SeatingChartView, add .offset:

.offset(offset + drag)

Finally, right below .offset, attach the drag gesture handler using .simultaneousGesture:

.simultaneousGesture(dragging)

SwiftUI can handle multiple gestures at the same time. Use .simultaneousGesture to indicate that you’d like to enable more than one gesture giving them equal priority.

Currently, you have two gesture handlers: dragging and the tap gesture handler for the tribunes. You’ll add a few more soon. Now, when you run the app, the chart is easily draggable:

img

Zooming

Like DragGesture, you can use MagnificationGesture to obtain the current gesture’s scale.

Add a new property to SeatingChartView:

@GestureState private var manualZoom = 1.0

Then, create a gesture handler:

var magnification: some Gesture {
  MagnificationGesture()
    .updating($manualZoom) { currentState, gestureState, transaction in
      gestureState = currentState
    }
    .onEnded {
      zoom *= $0
    }
}

Now, update .scaleEffect and move it above .rotationEffect:

.scaleEffect(manualZoom * zoom, anchor: zoomAnchor)

Finally, attach the gesture handler below dragging:

.simultaneousGesture(magnification)

Run the app and try to zoom the chart.

If you run it on a simulator, hold the Option (⌥) key and drag the chart with your mouse to emulate a magnification gesture.

img

Rotating

The 2D rotating gesture is as easily implemented in SwiftUI as the others. You know what to do! Add another @GestureState property, and add a rotation property keep track of the applied rotation:

@GestureState private var currentRotation: Angle = .radians(0.0)
@State var rotation = Angle(radians: .pi / 2)

Don’t forget about the corresponding gesture handler:

var rotationGesture: some Gesture {
  RotationGesture()
    .updating($currentRotation) { currentState, gestureState, transaction in
      gestureState = .radians(currentState.radians)
    }
    .onEnded {
      rotation += $0
    }
}

Update .rotationEffect of SeatingChartView:

.rotationEffect(rotation + currentRotation, anchor: zoomAnchor)

Last but not least, add the gesture handler below the two previous ones:

.simultaneousGesture(rotationGesture)

img

That was a piece of cake, right? :] Next, you’ll implement seat selection and add some bells and whistles.

Handling Seat Selection

To keep track of the selected seats, add a new property to SeatingChartView:

@State private var selectedSeats: [Seat] = []

A tap gesture to pick a tribune and one to pick a seat should be mutually exclusive: they can’t co-occur. Therefore, it makes sense to handle both in one gesture handler and decide which one should occur depending on the coordinates of the touch.

Remove .onTapGesture from the tribune, and add a new .onTapGesture to the ZStack above .scaleEffect:

.onTapGesture { tap in
  if let selectedTribune, selectedTribune.path.contains(tap) {
    // TODO pick a seat
  } else {
    // TODO pick a tribune
  }
}

Now, if a user has already selected a tribune and the touch occurred inside its bounds, it’s safe to assume the user tapped a seat. Otherwise, they’ve chosen a tribune.

To handle seat selection, create a new method in SeatingChartView:

private func findAndSelectSeat(at point: CGPoint, in selectedTribune: Tribune) {
  guard let seat = selectedTribune.seats
    .first(where: { $0.path.boundingRect.contains(point) }) else {
    return
  } // 1

  withAnimation(.easeInOut) {
    if let index = selectedSeats.firstIndex(of: seat) { // 2
      selectedSeats.remove(at: index)
    } else {
      selectedSeats.append(seat)
    }
  }
}

Here’s a breakdown:

  1. First, you search for a seat containing the coordinates of the touch among the selected tribune’s seats. If there is none, you return immediately.
  2. Finally, you select or deselect the seat depending on whether the seat is present in selectedSeats.

Now, add another method to handle a tribune selection:

private func findAndSelectTribune(at point: CGPoint, with proxy: GeometryProxy) {
  let tribune = tribunes.flatMap(\.value)
    .first(where: { $0.path.boundingRect.contains(point) })
  let unselected = tribune == selectedTribune
  let anchor = UnitPoint(
    x: point.x / proxy.size.width,
    y: point.y / proxy.size.height
  )

  LinkedAnimation.easeInOut(for: 0.7) {
    zoom = unselected ? 1.25 : 25
  }
  .link(
    to: .easeInOut(for: 0.3) {
      selectedTribune = unselected ? nil : tribune
      zoomAnchor = unselected ? .center : anchor
      offset = .zero
    },
    reverse: !unselected
  )
}

Like the seat selection, you first search for the tribune containing the needed coordinates. After that, you proceed the way you have since the previous chapter, except the offset is reset to .zero when zooming in or out.

Update .onTapGesture to invoke the newly created methods:

if let selectedTribune, selectedTribune.path.contains(tap) {
  findAndSelectSeat(at: tap, in: selectedTribune)
} else {
  findAndSelectTribune(at: tap, with: proxy)
}

Now update a seat’s .fill depending on whether the user has selected it. Replace:

seat.path.fill(.blue)

With:

seat.path.fill(selectedSeats.contains(seat) ? .green : .blue)

As the last step, remove .coordinateSpace from the ZStack. Now all touch events occur in the same view, so there’s no need to convert the coordinate space.

Check the preview or run the app:

img

You’re so close to the finish line with only a few things left to polish.

Final Animating Touches

Since a seat is essentially a Path, just like a tribune, it’s pretty easy to animate it by trimming it. Add a new property of type CGFloat to SeatingChartView:

@State private var seatsPercentage: CGFloat = .zero

Find seat.path and trim the seat’s stroke and fill:

seat.path
  .trim(from: 0, to: seatsPercentage)
  .fill(selectedSeats.contains(seat) ? .green : .blue)
seat.path
  .trim(from: 0, to: seatsPercentage)
  .stroke(.black, lineWidth: 0.05)

Go back to findAndSelectTribune and add the following line below anchor:

seatsPercentage = selectedTribune == nil || !unselected ? 0.0 : 1.0

Now, the animation will reset every time you select a new tribune.

Additionally, update seatsPercentage in the first of the two linked animations, right below zoom:

seatsPercentage = unselected ? 0.0 : 1.0

Check it out:

img

To make SeatsSelectionView aware of the changes happening in SeatingChartView, add the following @Binding properties to SeatingChartView:

@Binding var zoomed: Bool
@Binding var selectedTicketsNumber: Int

Increment or decrement selectedTicketsNumber in findAndSelectSeat(at:in:)inside withAnimation accordingly:

if let index = selectedSeats.firstIndex(of: seat) {
  selectedTicketsNumber -= 1
  selectedSeats.remove(at: index)
} else {
  selectedTicketsNumber += 1
  selectedSeats.append(seat)
}

Then, update zoomed in MagnificationGesture’s .onEnded callback. Add this code below zoom *= $0:

withAnimation {
  zoomed = zoom > 1.25
}

In findAndSelectTribune(at:with:) in the first linked animation, add:

zoomed = !unselected

Then, to zoom out and reset the chart, if zoomed gets updated from SeatsSelectionView, add .onChange to the ZStack in SeatingChartView:

.onChange(of: zoomed) {
  if !$0 && zoom > 1.25 {
    LinkedAnimation.easeInOut(for: 0.7) {
      zoom = 1.25
      seatsPercentage = 0.0
    }
    .link(
      to: .easeInOut(for: 0.3) {
        selectedTribune = nil
        zoomAnchor = .center
        offset = .zero
      },
      reverse: false
    )
  }
}

Update the preview of SeatingChartView to include the new initializer arguments:

SeatingChartView(
  zoomed: Binding.constant(false),
  selectedTicketsNumber: Binding.constant(5)
)

Finally, return to SeatSelectionView, and add these @State properties:

@State private var stadiumZoomed = false
@State private var selectedTicketsNumber: Int = 0
@State private var ticketsPurchased: Bool = false

Update SeatingChartView’s initializer:

SeatingChartView(
  zoomed: $stadiumZoomed,
  selectedTicketsNumber: $selectedTicketsNumber
)

Now, wrap the inner VStack containing the team name and the cart icon into the if-statement, and add a .transition:

if !stadiumZoomed {
  VStack { ... }
    .transition(.move(edge: .top))
}

Now when a user zooms on the chart, the title and icon go out of sight to make the screen less cluttered.

To indicate the number of selected tickets, wrap the cart icon in a ZStack, and add a label:

ZStack(alignment: .topLeading) {
  /* cart icon */
  if selectedTicketsNumber > 0 {
    Text("\(selectedTicketsNumber)")
      .foregroundColor(.white)
      .font(.caption)
      .background {
        Circle()
          .fill(.red)
          .frame(width: 16, height: 16)
      }
      .alignmentGuide(.leading) { _ in -20}
      .alignmentGuide(.top) { _ in 4 }
  }
}

Using the alignment guides, you adjust the label to appear on the top right corner of the icon.

To reset the gestures quickly, add a zoom-out button below the Buy Tickets button and wrap both into an HStack:

HStack {
  /* Buy Tickets button */

  if stadiumZoomed {
    Button {
      withAnimation {
        stadiumZoomed = false
      }
    } label: {
      Image("zoom_out")
        .resizable()
        .scaledToFit()
        .frame(width: 48, height: Constants.iconSizeL)
        .clipped()
        .background {
          RoundedRectangle(cornerRadius: 36)
            .fill(.white)
            .frame(width: 48, height: 48)
            .shadow(radius: 2)
          }
          .padding(.trailing)
    }
  }
}

Update the action of the Buy Tickets button:

if selectedTicketsNumber > 0 {
  ticketsPurchased = true
}

Finally, you need to show a pop-up to tell the user that the purchase was successful. Add .confirmationDialog to the root view, right below background(Constants.orange, ignoresSafeAreaEdges: .all):

.confirmationDialog(
  "You've bought \(selectedTicketsNumber) tickets.",
  isPresented: $ticketsPurchased,
  actions: { Button("Ok") {} },
  message: { Text("You've bought \(selectedTicketsNumber) tickets. Enjoy your time at the game!")}
)

Ta-da! You’ve done it! Run the app to see the final result:

img

Key Points

  1. CGAffineTransform represents a transformation matrix, which you can apply to a subpath to perform rotation, scaling, translating or skewing.
  2. A transformation matrix in 2D graphics is of size 3x3, where the first two columns are responsible for all the applied transformations. The last one is constant to preserve the matrices’ concatenation ability.
  3. An object rotates around its origin when manipulated by a rotation matrix. To use a different point as an anchor, move the object towards that point first, apply the desired rotation and then shift it back.
  4. SwiftUI can process multiple gestures, like DragGesture, MagnificationGesture, RotationGesture or TapGesture, simultaneously when you attach them with the .simultaneousGesture modifier.

Where to Go From Here?

Transformation matrices are still universally used in computer graphics regardless of the programming language, framework or platform. Learning them once will be handy when working with animations outside the Apple ecosystem. The Wikipedia article on the topicoffers a good overview of transformation matrices as a mathematical concept, also in the context of 2D or 3D computer graphics.

Additionally, if matrices don’t scare but excite you, and you want to dive deep into Metal, Apple’s low-level computer graphics framework, Metal by Tutorials can guide you step-by-step along your journey.